Skip to content

Fix: Propagate every plugin header mutation in extproc and forwardproxy - #760

Open
JoshSag wants to merge 4 commits into
rossoctl:mainfrom
s-and-p-team:lane/listener-header-parity
Open

Fix: Propagate every plugin header mutation in extproc and forwardproxy#760
JoshSag wants to merge 4 commits into
rossoctl:mainfrom
s-and-p-team:lane/listener-header-parity

Conversation

@JoshSag

@JoshSag JoshSag commented Aug 16, 2026

Copy link
Copy Markdown

The problem

In extproc and forwardproxy, a plugin's write to pctx.Headers never reaches
the wire unless the header is named Authorization: both handlers compared that
one header before and after the pipeline and emitted only it. reverseproxy
already syncs the whole header set (listener/reverseproxy/server.go:270-289).
This brings the other two to parity.

Who this affects today

plugin writes effect before this PR
staticinject a configurable header name (plugin.go:221) never reaches the backend
staticinject Del("Authorization") (plugin.go:229) deletion ignored — the old path only ever set
cpex arbitrary key/value pairs (manager_cpex.go:492) dropped
tokenexchange, tokenbroker, jwtvalidation Authorization unaffected

It is a correctness fix to your own plugins, independent of anything we run.

The change

extproc gains a generic withHeaderMutation: diff pctx.Headers against a
clone taken before the pipeline ran, emit the difference as SetHeaders /
RemoveHeaders. It skips :-prefixed pseudo-headers and Content-Length /
Content-Encoding, the same exclusions reverseproxy makes. forwardproxy
takes the equivalent sync.

Both retire the Authorization special case. Every upstream writer emits
"Bearer " + token, so the extract-and-re-prefix path was the identity function
on all real inputs, and it mangled non-Bearer schemes. After this, no header is
special in any listener.

A zero-length value is treated as a delete rather than an empty SetHeaders, so
the outcome no longer depends on Envoy's keep_empty_value.

authorityOf is a 4-line helper consolidating the existing
:authority-then-host fallback, used at the two outbound sites only. The
first revision also populated inbound pctx.Host; review correctly flagged that
the inbound authority is caller-controlled and pctx.Host feeds enforcement —
ibac's host-bypass, opa's policy input, per-host JWT audiences. Those hunks are
dropped, and TestExtProc_Authority now pins inbound Host to empty as a
security property.

Tests

Listener-level, in three new files: the assertion is about what appears on the
ProcessingResponse or on the upstream request — a boundary a plugin-level test
cannot observe.

extproc      Outbound   TraceRewriteReachesWire · Body_TraceRewriteReachesWire
                        UnchangedTraceHeadersEmitNothing · ArbitraryHeaderReachesWire
                        DeletedHeaderIsRemoved · PseudoHeadersNeverEmitted
                        NilValueHeaderIsRemoved
             Inbound    ArbitraryHeaderReachesWire · DeletedHeaderIsRemoved
                        Body_ArbitraryHeaderReachesWire
             Authority  outbound populated; inbound pinned EMPTY

forwardproxy            ArbitraryHeaderReachesWire · OverwrittenHeaderReachesWire
                        DeletedHeaderIsRemoved · UnchangedHeaderPreserved

Each behavioural test was checked by reverting its fix and confirming it fails.

No lineage vocabulary in the production diff

git diff main...<branch> -- authbridge/authlib/listener/extproc/server.go \
                            authbridge/authlib/listener/forwardproxy/server.go \
  | grep -inE "lineage|traceparent|tracestate|exchange\.id"   # no matches

The new test files do mention traceparent/tracestate: the fixture plugin rewrites
those headers, chosen because header mutations that must survive to the wire are
exactly what this fix is about.

To be explicit after review: outbound Host feeds SessionEvent.Host and its
telemetry consumers, including our lineage plugin's peer-host fact, and that
consumption is what motivated the outbound consolidation. The inbound half served
no need of ours and is gone.

Verification

golang:1.26, mirroring ci.yaml: authlib vet / build / test -race -cover
46 packages ok, 0 failed · both cmd/authbridge-* with GOWORK=off · lite
variant (7 exclude_plugin_* tags) build and test · go mod tidy byte-clean
across 3 modules · gofmt -l → 16 pre-existing dirty files in authlib, none of
the 6 this branch touches.

Out of scope

extauthz (waypoint mode) has the same Authorization-only pattern
(extauthz/server.go:86-92). We do not run that mode and cannot test it end to
end; the shape of this fix should transfer directly.


Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary by CodeRabbit

  • New Features

    • Plugin-driven request header additions, updates, and removals are now forwarded correctly.
    • Header changes made during body processing are preserved.
    • Transport-managed and HTTP/2 pseudo-headers remain protected from unintended changes.
  • Bug Fixes

    • Deleted or empty-valued headers are now removed correctly.
    • Authority handling is more consistent, with outbound requests using :authority and falling back to host.
    • Unchanged headers are preserved without unnecessary modifications.

reverseproxy already syncs the pipeline's whole header set onto the forwarded
request, and its comment states the bug it fixed:

    Only Authorization used to be forwarded, silently dropping any other
    injected header (e.g. static-inject's x-api-key).

extproc and forwardproxy still behave the way that comment describes. This
brings them to parity.

extproc gains a generic withHeaderMutation: diff pctx.Headers against a clone
taken before the pipeline ran, emit the difference as SetHeaders/RemoveHeaders.
It skips ':'-prefixed pseudo-headers, which govern routing, and
Content-Length/Content-Encoding, which the body-rewrite path and the transport
manage — the same exclusions reverseproxy makes. forwardproxy takes the
equivalent block.

Both drop the Authorization special case. Every writer in-tree emits
"Bearer "+token, so extract-and-re-prefix was the identity function on all real
inputs, and it mangled non-Bearer schemes because ExtractBearer returns empty
for them. Removing it takes three lines out of each of the four ext_proc
handlers and drops the auth import from the file. No header is special in any
listener now.

Affected today, with no telemetry involved: static-inject writes a configurable
header name (plugin.go:221) and deletes Authorization (plugin.go:229) — neither
reached the wire, the deletion because the old path only ever set that header.
cpex writes arbitrary pairs (manager_cpex.go:492).

Also here, separable in review: a 4-line authorityOf helper used at five sites.
The inbound ext_proc handlers never set pctx.Host while the outbound ones did,
though pipeline.SessionEvent documents Host for both directions and reverseproxy
always populated it. A 107-line table test covers every handler site and both
header forms.

Six listener-level regression tests come with this, asserting at the
ProcessingResponse layer that a plugin-level test cannot observe. Three use
ordinary header names to pin the general behaviour: an arbitrary header reaches
the wire, a deleted header is removed, pseudo-headers are never emitted.

Out of scope: extauthz (waypoint mode) has the same Authorization-only pattern
at server.go:86-92 and is untouched here.

Signed-off-by: YehoshuaSagron <ysagron@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The ext-proc listener now applies directional authority handling and generalized header mutations. The forward-proxy listener forwards added, replaced, and deleted headers. Tests cover authority resolution, body phases, arbitrary headers, deletions, and excluded headers.

Changes

Pipeline header propagation

Layer / File(s) Summary
Directional authority context
authbridge/authlib/listener/extproc/server.go, authbridge/authlib/listener/extproc/server_authority_test.go
Inbound contexts leave Pipeline.Context.Host empty. Outbound contexts use :authority and fall back to host.
Ext-proc header mutation generation
authbridge/authlib/listener/extproc/server.go, authbridge/authlib/listener/extproc/server_headerdiff_test.go, authbridge/authlib/listener/extproc/placeholder_test.go
The listener emits mutations for changed, added, empty, and nil-valued headers. It emits deletions through RemoveHeaders, excludes pseudo-headers and transport-managed content headers, and composes header mutations with body mutations.
Forward-proxy header synchronization
authbridge/authlib/listener/forwardproxy/server.go, authbridge/authlib/listener/forwardproxy/server_headerdiff_test.go
The forward-proxy listener synchronizes added, replaced, and deleted pipeline headers. Empty values delete headers, while Content-Length and Content-Encoding remain excluded from body transport handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🔵 Low · up to a8982

The PR correctly propagates plugin header changes, but transport-managed headers with different casing could still be forwarded incorrectly. The change is mergeable with explicit owner awareness and follow-up to make these exclusions case-insensitive.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ExtProcListener
  participant Pipeline
  participant ForwardProxy
  participant Upstream
  Client->>ExtProcListener: Send request
  ExtProcListener->>Pipeline: Process request headers or body
  Pipeline-->>ExtProcListener: Return header and body mutations
  ExtProcListener->>ForwardProxy: Emit set and remove header mutations
  ForwardProxy->>Upstream: Forward synchronized headers and body
Loading

Suggested reviewers: huang195, ibrahim2595

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: propagating all plugin header mutations in extproc and forwardproxy.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@huang195
huang195 marked this pull request as ready for review August 17, 2026 19:05
@huang195
huang195 requested a review from a team as a code owner August 17, 2026 19:05

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header-propagation change is correct and worth taking; I verified the parts that could bite:

  • forwardproxy (server.go:325-347) — checked ordering: pctx.Headers is a full r.Header.Clone() (line 220), the hop-by-hop strip (lines 359-368) runs after the sync so Proxy-Authorization can't be re-introduced upstream, and the pctx.BodyMutated() block still owns Content-Length. No leak.
  • Authorization special case removal — confirmed every pctx.Headers writer emits "Bearer "+token (jwtvalidation:381, tokenbroker:303, tokenexchange:707), so ExtractBearer + re-prefix was indeed the identity function on real inputs. placeholder_test.go covers the inbound Authorization path through the handlers, so that path keeps regression coverage.
  • append_action — Envoy's ext_proc reads the deprecated append bool (default false → setCopy), per mutation_utils.cc:165-177, so omitting AppendAction replaces rather than appends. Matches the retired helpers' behaviour.
  • No double-emit — header and body phases are mutually exclusive (server.go:113-138).
  • No plugin uses pctx.Headers as a scratchpad, so generalizing propagation leaks nothing internal; honouring staticinject's Del("Authorization") (plugin.go:229) is a security improvement in its own right.

One blocker: the bundled authorityOf change populates inbound Host from a caller-controlled authority, and inbound pctx.Host feeds ibac's un-guarded host-bypass, opa's policy input, and jwtvalidation's per-host audience. That needs to be split out or guarded before merge. Details inline.

Areas reviewed: Go (ext_proc / forward-proxy listeners), tests
Commits: 1 commit, signed off
CI status: all 19 checks passing (CodeRabbit still running)

Direction: pipeline.Inbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Host: authorityOf(headers),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — The header-propagation fix is sound, but this hunk (and its twin at line 190) is not the neutral telemetry fix the description claims. On the inbound path :authority/Host is caller-controlled, and pctx.Host is read by decision-making plugins, not just recorded:

  • plugins/ibac/plugin.go:352matchesAnyHost(p.bypassHosts, pctx.Host)pctx.Skip("host_bypass"), with no direction guard. defaultBypassHosts includes keycloak/spire/otel, plus whatever agent_llm_host is set to. Today in ext_proc inbound this branch is inert because pctx.Host is ""; after this change a caller who sets Host: keycloak... skips IBAC judging entirely.
  • plugins/opa/plugin.go:525"host": pctx.Host becomes caller-controlled policy input.
  • plugins/jwtvalidation/plugin.go:393 — with audience_mode: per-host, the expected audience is derived from the caller-supplied authority.

This repo already documents the hazard and guards for it: plugins/cpex/plugin.go:306-310 gates matchesAnyHost behind pctx.Direction == pipeline.Outbound, with the comment "the Host header is attacker-controlled and identity has NOT been pre-validated."

Two ways forward, either is fine: (a) drop the two inbound authorityOf hunks and keep the outbound consolidation (lines 469/511, a pure no-op refactor); or (b) land them together with a direction guard on ibac's host-bypass check. Worth noting reverseproxy already populates inbound Host, so ibac's exposure pre-dates this PR — but this widens it to the ext_proc sidecar path, and that shouldn't ride along in a PR framed as header propagation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took option (a) — both inbound authorityOf hunks are dropped, the outbound consolidation stays. TestExtProc_Authority now pins inbound pctx.Host to empty using keycloak… fixtures, so the property is pinned rather than just restored.

Agreed it shouldn't have ridden along in a header-propagation PR. The reverseproxy inbound exposure you noted is left untouched for the same reason — happy to open a follow-up issue for ibac's missing direction guard if you'd like it tracked.

// headerMapToHTTP copies into pctx.Headers and whose :authority governs routing;
// and Content-Length / Content-Encoding, managed by withBodyMutation and the
// transport.
func withHeaderMutation(resp *extprocv3.ProcessingResponse, pctx *pipeline.Context, orig http.Header) *extprocv3.ProcessingResponse {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — With the Authorization special case retired, replaceTokenResponse (line 888) and replaceTokenBodyResponse (line 863) have no callers left. The five references in placeholder_test.go (lines 14, 31, 103, 106, 132) are comments, not calls, and now describe a path production no longer takes. Deleting both helpers and rewording those comments to name withHeaderMutation keeps the next reader from tracing a dead path.

// (whose separator is "; ") — no plugin rewrites Cookie today, and
// one that does must split this out rather than discover it here.
set = append(set, &corev3.HeaderValueOption{
Header: &corev3.HeaderValue{Key: strings.ToLower(k), RawValue: []byte(strings.Join(vv, ","))},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — Two edges worth a line of comment or a follow-up:

  • headerMapToHTTP (line 766) uses h.Set, so a header that arrived on the wire with duplicate entries is already collapsed to its last value in pctx.Headers. Unchanged headers emit nothing so nothing regresses, but for a header a plugin does mutate, the emitted SetHeaders replaces all wire values with the collapsed one.
  • A plugin doing pctx.Headers[k] = nil instead of Del(k) lands here rather than in the remove loop, emitting an empty RawValue — Envoy drops empty values without keep_empty_value, so the effect is right by accident. Treating a zero-length slice as a delete makes it right by construction.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both taken, with one correction worth making.

In extproc a zero-length value now lands in RemoveHeaders, so the outcome no longer depends on keep_empty_value — pinned by TestExtProc_Outbound_NilValueHeaderIsRemoved, which fails against the previous code with set_headers:{header:{key:"x-drop-me"}}.

In forwardproxy the same change alters no bytes: net/http already omits a header whose value slice is empty, so the previous line was correct on the wire and only the in-memory map differs. It is a consistency change, and no wire-level test can distinguish the two spellings. Happy to drop that hunk if you'd rather the PR carry only behavioural changes.

The duplicate-header collapse is now stated in the withHeaderMutation comment.


// TestExtProc_Outbound_DeletedHeaderIsRemoved: a plugin deleting a header
// must emit RemoveHeaders — the narrow two-name diff could not express this.
func TestExtProc_Outbound_DeletedHeaderIsRemoved(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — All six new cases go through outboundRequest. handleInbound/handleInboundBody took the identical change, and inbound coverage today is only placeholder_test.go's Authorization case — the one header that worked before. One inbound variant of ArbitraryHeaderReachesWire + DeletedHeaderIsRemoved would pin the general behaviour on both paths; the harness already supports it.

)

// hostCapture records the pctx.Host the listener built, so a test can assert
// what plugins actually see (Host is what SessionEvent.Host and the lineage

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — This comment says Host is what "the lineage plugin's lineage.peer.host fact" is derived from, which is hard to square with "That is an upstream omission rather than anything we need" in the PR body. The grep in the description is scoped to the two production files, so it's accurate as written — but the honest framing matters here, because the motivation is exactly what a reviewer weighs against the inbound-authority risk in my other comment.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair hit. Outbound Host feeds SessionEvent.Host and its telemetry consumers — including our lineage plugin's peer-host fact — and that is why the authority change was in the PR at all. The "not anything we need" line was accurate only for the inbound half, and the test comment you quoted was the tell.

The inbound half is dropped per your other comment, the hostCapture comment now describes both roles, and the PR body has been updated to match.

faraway7 and others added 3 commits August 20, 2026 12:03
PR rossoctl#760 gave forwardproxy the same generic header-sync as extproc/
reverseproxy but added no forwardproxy tests; the set/replace/delete
behaviour was covered only indirectly via Authorization. Add four
listener-level regression tests that assert on the headers the upstream
backend actually receives:

  TestForwardProxy_ArbitraryHeaderReachesWire   (plugin-Set x-api-key)
  TestForwardProxy_OverwrittenHeaderReachesWire (Set replaces client value)
  TestForwardProxy_DeletedHeaderIsRemoved       (plugin Del strips it)
  TestForwardProxy_UnchangedHeaderPreserved     (untouched header survives)

All four fail against the pre-PR Authorization-only path and pass on the
generic sync, mirroring the extproc server_headerdiff_test.go suite.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Igor Gokhman <igorgok@il.ibm.com>
The PR retired the Authorization special case in the four ext_proc
handlers in favour of the generic withHeaderMutation diff, leaving
replaceTokenResponse and replaceTokenBodyResponse with no callers. They
compiled only because Go does not flag unused package-level functions and
CI runs no unused-code linter; the only remaining mentions were stale
comments in placeholder_test.go describing the removed mechanism.

Delete both functions and refresh the placeholder_test.go comments to
name the withHeaderMutation path the tests actually exercise. No
behaviour change: TestExtProc_Inbound{,Body}_AuthorizationMutation still
assert the same SetHeaders mutation and pass.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Igor Gokhman <igorgok@il.ibm.com>
… inbound tests

Review response (PR rossoctl#760), rebased onto the pushed dead-helper removal:

- Drop the inbound authorityOf hunks: inbound :authority/Host is
  caller-controlled and pctx.Host feeds enforcement (ibac host-bypass,
  opa policy input, per-host JWT audiences). The outbound consolidation
  stays; TestExtProc_Authority now pins inbound Host to empty as a
  security property.
- Treat a zero-length pctx.Headers value as a delete in both listeners,
  so pctx.Headers[k] = nil removes the header by construction instead
  of relying on Envoy dropping empty values.
- Add inbound coverage on both handlers the review named: header-path
  twins of ArbitraryHeaderReachesWire / DeletedHeaderIsRemoved, a
  body-path twin, and a nil-value-is-removed regression test; note the
  duplicate-header collapse in the withHeaderMutation comment.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: YehoshuaSagron <ysagron@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
authbridge/authlib/listener/extproc/server.go (1)

699-703: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make transport-header exclusions case-insensitive.

Direct assignments preserve header-key casing. A lower-case content-encoding entry bypasses the exact-case filter and can forward a transport-managed header. Use strings.EqualFold for both names in the extproc, forwardproxy, and reverseproxy header-sync filters.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@authbridge/authlib/listener/extproc/server.go` around lines 699 - 703, Update
the header-sync skip filters in withHeaderMutation and the corresponding
forwardproxy and reverseproxy filters to compare Content-Length and
Content-Encoding case-insensitively using strings.EqualFold, while preserving
the existing pseudo-header exclusion and other behavior. Affected sites:
authbridge/authlib/listener/extproc/server.go lines 699-703,
authbridge/authlib/listener/forwardproxy/server.go lines 325-350, and the
corresponding reverseproxy header-sync filter; apply the same change at each
site.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Outside diff comments:
In `@authbridge/authlib/listener/extproc/server.go`:
- Around line 699-703: Update the header-sync skip filters in withHeaderMutation
and the corresponding forwardproxy and reverseproxy filters to compare
Content-Length and Content-Encoding case-insensitively using strings.EqualFold,
while preserving the existing pseudo-header exclusion and other behavior.
Affected sites: authbridge/authlib/listener/extproc/server.go lines 699-703,
authbridge/authlib/listener/forwardproxy/server.go lines 325-350, and the
corresponding reverseproxy header-sync filter; apply the same change at each
site.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 25421902-7dfa-4c08-b395-c41d35c4e931

📥 Commits

Reviewing files that changed from the base of the PR and between 4ddbc1e and a898234.

📒 Files selected for processing (5)
  • authbridge/authlib/listener/extproc/placeholder_test.go
  • authbridge/authlib/listener/extproc/server.go
  • authbridge/authlib/listener/extproc/server_authority_test.go
  • authbridge/authlib/listener/extproc/server_headerdiff_test.go
  • authbridge/authlib/listener/forwardproxy/server.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

@JoshSag
JoshSag requested a review from huang195 August 20, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

4 participants